1use std::{
9 collections::HashMap,
10 ffi::c_void,
11 sync::{
12 Arc, Mutex, Weak,
13 atomic::{AtomicU64, Ordering},
14 },
15 thread,
16 time::{Duration, Instant},
17};
18
19use crate::pp_log::{PpLog, pp_info};
20use arc_swap::ArcSwapOption;
21use ffmpeg_next as ffmpeg;
22use thiserror::Error as ThisError;
23use windows::{
24 Win32::Foundation::RECT,
25 Win32::Graphics::{
26 Direct3D::{
27 D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST, D3D_SRV_DIMENSION_TEXTURE2DARRAY, Fxc::*,
28 ID3DBlob, ID3DInclude,
29 },
30 Direct3D11::*,
31 Dxgi::Common::{
32 DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_R8_UNORM,
33 DXGI_FORMAT_R8G8_UNORM, DXGI_SAMPLE_DESC,
34 },
35 },
36 core::{Interface, s},
37};
38
39mod text_handle;
40mod video_handle;
41
42pub use text_handle::{D3d11TextLayerError, D3d11TextLayerHandle};
43pub use video_handle::D3d11VideoLayerHandle;
44
45use super::super::{
46 text_layer::TextLayer,
47 video_layer::{self, LayerGeometry, MAX_DIMENSION, VideoLayer, VideoLayerError},
48};
49use crate::{
50 buffer::MediaBuffer,
51 bus::{Bus, BusEvent},
52 control::{ControlMsg, ControlReceiver, drain_control},
53 element::{Element, ElementType, Sink, Source, SourceElement, element_pp_log},
54 elements::{
55 VideoCompositorOptions,
56 filter::decoder::d3d11va_decoder::{d3d11va_texture, wrap_d3d11_texture},
57 },
58 error::Result,
59 pad::SrcPad,
60 pool::{UnboundObjectPool, UnboundObjectPoolRef},
61 schedule::PeriodicSchedule,
62};
63
64const OUTPUT_POOL_SIZE: usize = 4;
65const CONTROL_POLL_INTERVAL: Duration = Duration::from_millis(5);
66
67const BGRA_SHADER_SOURCE: &[u8] =
68 include_bytes!("../../../../../shaders/d3d11/composite_bgra.hlsl");
69const NV12_SHADER_SOURCE: &[u8] =
70 include_bytes!("../../../../../shaders/d3d11/composite_nv12.hlsl");
71
72#[derive(Debug, ThisError)]
74pub enum D3d11VideoCompositorError {
75 #[error("windows error: {0}")]
76 Windows(#[from] windows::core::Error),
77
78 #[error(
79 "invalid output dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
80 )]
81 InvalidOutputDimensions { width: u32, height: u32 },
82
83 #[error("invalid frame rate {0}; numerator and denominator must both be positive")]
84 InvalidFrameRate(ffmpeg::Rational),
85
86 #[error(
87 "invalid layer dimensions {width}x{height}; each dimension must be 1..={MAX_DIMENSION}"
88 )]
89 InvalidLayerDimensions { width: u32, height: u32 },
90
91 #[error("layer opacity must be finite and between 0.0 and 1.0, got {0}")]
92 InvalidOpacity(f32),
93
94 #[error("input frame has invalid dimensions {width}x{height}")]
95 InvalidInputDimensions { width: u32, height: u32 },
96
97 #[error("scaled layer would exceed {MAX_DIMENSION}px: {width}x{height}")]
98 ScaledLayerTooLarge { width: u32, height: u32 },
99
100 #[error("the compositor input has been removed")]
101 SourceRemoved,
102
103 #[error(
104 "D3d11VideoCompositorInputSink only accepts Pixel::D3D11 frames, got {0:?}; \
105 upload/decode/capture to GPU first"
106 )]
107 UnsupportedFormat(ffmpeg::format::Pixel),
108
109 #[error(
110 "frame claimed the D3D11 pixel format but carries no texture — must come from \
111 D3d11Upload/D3d11Decoder/DxgiCaptureSource's GPU mode"
112 )]
113 InvalidD3d11Frame,
114
115 #[error(
116 "D3d11VideoCompositor only draws DXGI_FORMAT_B8G8R8A8_UNORM or DXGI_FORMAT_NV12 input \
117 textures, got {0:?}"
118 )]
119 UnsupportedTextureFormat(DXGI_FORMAT),
120
121 #[error("D3D11 texture array index {index} is outside ArraySize {array_size}")]
122 InvalidArrayIndex { index: isize, array_size: u32 },
123
124 #[error(
125 "frame dimensions {frame_width}x{frame_height} exceed the backing D3D11 texture's \
126 {texture_width}x{texture_height} dimensions"
127 )]
128 FrameExceedsTexture {
129 frame_width: u32,
130 frame_height: u32,
131 texture_width: u32,
132 texture_height: u32,
133 },
134
135 #[error(
136 "a Pixel::D3D11 frame's texture lives on a different ID3D11Device than this \
137 D3d11VideoCompositor was created with — every D3D11 element in one pipeline must share \
138 exactly one device for zero-copy to be valid"
139 )]
140 DeviceMismatch,
141
142 #[error(
143 "D3d11VideoCompositorInputSink only accepts decoded Video frames, got a {0}; link it \
144 after a decoder or video source"
145 )]
146 UnsupportedBuffer(&'static str),
147
148 #[error("D3d11VideoCompositor doesn't support seeking a live composition")]
149 SeekUnsupported,
150}
151
152fn map_layer_error(error: VideoLayerError) -> D3d11VideoCompositorError {
153 match error {
154 VideoLayerError::InvalidDimensions { width, height } => {
155 D3d11VideoCompositorError::InvalidLayerDimensions { width, height }
156 }
157 VideoLayerError::InvalidOpacity(opacity) => {
158 D3d11VideoCompositorError::InvalidOpacity(opacity)
159 }
160 VideoLayerError::InvalidInputDimensions { width, height } => {
161 D3d11VideoCompositorError::InvalidInputDimensions { width, height }
162 }
163 VideoLayerError::ScaledLayerTooLarge { width, height } => {
164 D3d11VideoCompositorError::ScaledLayerTooLarge { width, height }
165 }
166 }
167}
168
169struct GpuVideoInput {
170 id: video_layer::VideoInputId,
171 latest_frame: ArcSwapOption<UnboundObjectPoolRef<ffmpeg::frame::Video>>,
178 layer: Mutex<VideoLayer>,
179}
180
181struct D3d11CompositorShared {
182 inputs: Mutex<HashMap<Arc<str>, Arc<GpuVideoInput>>>,
183 next_input_id: AtomicU64,
184 device: ID3D11Device,
189}
190
191#[derive(Clone)]
195pub struct D3d11VideoCompositorHandle {
196 shared: Weak<D3d11CompositorShared>,
197}
198
199pub struct D3d11VideoCompositorInput {
201 pub sink: Box<dyn Sink>,
202 pub layer: D3d11VideoLayerHandle,
203}
204
205impl D3d11VideoCompositorHandle {
206 fn register_input(
213 &self,
214 name: impl Into<String>,
215 layer: VideoLayer,
216 ) -> std::result::Result<Option<D3d11VideoLayerHandle>, D3d11VideoCompositorError> {
217 video_layer::validate_layer(layer).map_err(map_layer_error)?;
218 let Some(shared) = self.shared.upgrade() else {
219 return Ok(None);
220 };
221 let name: Arc<str> = name.into().into();
222 let id = video_layer::VideoInputId(shared.next_input_id.fetch_add(1, Ordering::Relaxed));
223 let input = Arc::new(GpuVideoInput {
224 id,
225 latest_frame: ArcSwapOption::empty(),
226 layer: Mutex::new(layer),
227 });
228 shared
229 .inputs
230 .lock()
231 .unwrap()
232 .insert(name.clone(), input.clone());
233
234 Ok(Some(D3d11VideoLayerHandle {
235 id,
236 name,
237 input: Arc::downgrade(&input),
238 }))
239 }
240
241 pub fn add_source(
247 &self,
248 name: impl Into<String>,
249 layer: VideoLayer,
250 ) -> std::result::Result<Option<D3d11VideoCompositorInput>, D3d11VideoCompositorError> {
251 let Some(layer_handle) = self.register_input(name, layer)? else {
252 return Ok(None);
253 };
254 Ok(Some(D3d11VideoCompositorInput {
255 sink: Box::new(D3d11VideoCompositorInputSink {
256 name: layer_handle.name.clone(),
257 pp_log: element_pp_log(ElementType::D3d11VideoCompositor, &layer_handle.name, None),
258 shared: self.shared.clone(),
259 input: layer_handle.input.clone(),
260 }),
261 layer: layer_handle,
262 }))
263 }
264
265 pub fn add_layer(
271 &self,
272 name: impl Into<String>,
273 layer: VideoLayer,
274 ) -> std::result::Result<Option<D3d11VideoLayerHandle>, D3d11VideoCompositorError> {
275 self.register_input(name, layer)
276 }
277
278 pub fn remove_source(&self, name: &str) {
279 if let Some(shared) = self.shared.upgrade() {
280 shared.inputs.lock().unwrap().remove(name);
281 }
282 }
283
284 pub fn source_count(&self) -> usize {
285 self.shared
286 .upgrade()
287 .map(|shared| shared.inputs.lock().unwrap().len())
288 .unwrap_or(0)
289 }
290
291 pub fn add_text_layer(
303 &self,
304 name: impl Into<String>,
305 text_layer: TextLayer,
306 ) -> std::result::Result<Option<D3d11TextLayerHandle>, D3d11TextLayerError> {
307 let Some(device) = self.shared.upgrade().map(|shared| shared.device.clone()) else {
308 return Ok(None);
309 };
310 let font = D3d11TextLayerHandle::parse_font(text_layer.font_data, text_layer.font_size)?;
313 let placeholder = VideoLayer::new(video_layer::VideoRect::new(
317 text_layer.x,
318 text_layer.y,
319 1,
320 1,
321 ));
322 let Some(layer) = self.add_layer(name, placeholder)? else {
323 return Ok(None);
324 };
325 Ok(Some(D3d11TextLayerHandle::new(
326 layer,
327 &device,
328 font,
329 text_layer.font_size,
330 text_layer.color,
331 )))
332 }
333}
334
335pub struct D3d11VideoCompositorInputSink {
341 pp_log: PpLog,
342 name: Arc<str>,
343 shared: Weak<D3d11CompositorShared>,
344 input: Weak<GpuVideoInput>,
345}
346
347impl D3d11VideoCompositorInputSink {
348 fn detach(&self) {
349 let (Some(shared), Some(input)) = (self.shared.upgrade(), self.input.upgrade()) else {
350 return;
351 };
352 let mut inputs = shared.inputs.lock().unwrap();
353 let is_current = inputs
354 .get(&self.name)
355 .is_some_and(|current| Arc::ptr_eq(current, &input));
356 if is_current {
357 inputs.remove(&self.name);
358 }
359 }
360}
361
362impl Element for D3d11VideoCompositorInputSink {
363 fn name(&self) -> Arc<str> {
364 self.name.clone()
365 }
366
367 fn element_type(&self) -> ElementType {
368 ElementType::D3d11VideoCompositor
369 }
370
371 fn pp_log(&self) -> &PpLog {
372 &self.pp_log
373 }
374
375 fn pp_log_mut(&mut self) -> &mut PpLog {
376 &mut self.pp_log
377 }
378}
379
380impl Sink for D3d11VideoCompositorInputSink {
381 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
382 let Some(input) = self.input.upgrade() else {
383 return Ok(());
384 };
385 match buf {
386 MediaBuffer::Video(frame) => {
387 if frame.format() != ffmpeg::format::Pixel::D3D11 {
388 return Err(D3d11VideoCompositorError::UnsupportedFormat(frame.format()).into());
389 }
390 input.latest_frame.store(Some(frame));
391 Ok(())
392 }
393 MediaBuffer::Eos => {
394 self.detach();
395 Ok(())
396 }
397 MediaBuffer::Packet(_) => {
398 Err(D3d11VideoCompositorError::UnsupportedBuffer("Packet").into())
399 }
400 MediaBuffer::Audio(_) => {
401 Err(D3d11VideoCompositorError::UnsupportedBuffer("Audio").into())
402 }
403 }
404 }
405
406 fn control(&mut self, msg: ControlMsg) -> Result<()> {
407 match msg {
408 ControlMsg::Stop => self.detach(),
409 ControlMsg::Seek(_) => {
410 if let Some(input) = self.input.upgrade() {
411 input.latest_frame.store(None);
412 }
413 }
414 ControlMsg::Pause | ControlMsg::Resume => {}
415 }
416 Ok(())
417 }
418}
419
420#[derive(Clone)]
421struct InputSnapshot {
422 id: video_layer::VideoInputId,
423 layer: VideoLayer,
424 frame: Option<Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>>>,
425}
426
427struct OutputTarget {
428 texture: ID3D11Texture2D,
429 render_target_view: ID3D11RenderTargetView,
430}
431
432#[repr(C)]
433#[derive(Clone, Copy)]
434struct LayerConstants {
435 red: [f32; 4],
436 green: [f32; 4],
437 blue: [f32; 4],
438 opacity: f32,
439 _padding: [f32; 3],
440 uv_scale: [f32; 2],
441 _uv_padding: [f32; 2],
442}
443
444impl LayerConstants {
445 fn bgra(opacity: f32, uv_scale: [f32; 2]) -> Self {
446 Self {
447 red: [0.0; 4],
448 green: [0.0; 4],
449 blue: [0.0; 4],
450 opacity,
451 _padding: [0.0; 3],
452 uv_scale,
453 _uv_padding: [0.0; 2],
454 }
455 }
456
457 fn nv12(frame: &ffmpeg::frame::Video, opacity: f32, uv_scale: [f32; 2]) -> Self {
458 let [red, green, blue] =
459 yuv_to_rgb_rows(frame.color_space(), frame.color_range(), frame.height());
460 Self {
461 red,
462 green,
463 blue,
464 opacity,
465 _padding: [0.0; 3],
466 uv_scale,
467 _uv_padding: [0.0; 2],
468 }
469 }
470}
471
472fn visible_uv_scale(
473 frame_width: u32,
474 frame_height: u32,
475 texture_width: u32,
476 texture_height: u32,
477) -> std::result::Result<[f32; 2], D3d11VideoCompositorError> {
478 if frame_width > texture_width || frame_height > texture_height {
479 return Err(D3d11VideoCompositorError::FrameExceedsTexture {
480 frame_width,
481 frame_height,
482 texture_width,
483 texture_height,
484 });
485 }
486 Ok([
487 frame_width as f32 / texture_width as f32,
488 frame_height as f32 / texture_height as f32,
489 ])
490}
491
492pub struct D3d11VideoCompositor {
512 pp_log: PpLog,
513 name: Arc<str>,
514 shared: Arc<D3d11CompositorShared>,
515 options: VideoCompositorOptions,
516 frame_interval: Duration,
517 frame_index: i64,
518 device: ID3D11Device,
519 context: Arc<Mutex<ID3D11DeviceContext>>,
520 vertex_shader: ID3D11VertexShader,
521 bgra_pixel_shader: ID3D11PixelShader,
522 nv12_pixel_shader: ID3D11PixelShader,
523 sampler: ID3D11SamplerState,
524 blend_state: ID3D11BlendState,
525 rasterizer_state: ID3D11RasterizerState,
526 layer_buffer: ID3D11Buffer,
527 output_pool: UnboundObjectPool<ffmpeg::frame::Video>,
531 output_views: HashMap<usize, ID3D11RenderTargetView>,
533 pad: SrcPad,
534}
535
536unsafe impl Send for D3d11VideoCompositor {}
542
543impl D3d11VideoCompositor {
544 pub fn new(
557 name: impl Into<String>,
558 device: &ID3D11Device,
559 context: Arc<Mutex<ID3D11DeviceContext>>,
560 options: VideoCompositorOptions,
561 ) -> std::result::Result<(Self, D3d11VideoCompositorHandle), D3d11VideoCompositorError> {
562 validate_output_options(options)?;
563 let name: Arc<str> = name.into().into();
564 let pp_log = element_pp_log(ElementType::D3d11VideoCompositor, &name, None);
565 let shared = Arc::new(D3d11CompositorShared {
566 inputs: Mutex::new(HashMap::new()),
567 next_input_id: AtomicU64::new(1),
568 device: device.clone(),
569 });
570 let frame_interval = Duration::from_secs_f64(
571 options.frame_rate.denominator() as f64 / options.frame_rate.numerator() as f64,
572 );
573
574 let (
575 vertex_shader,
576 bgra_pixel_shader,
577 nv12_pixel_shader,
578 sampler,
579 blend_state,
580 rasterizer_state,
581 layer_buffer,
582 ) = unsafe { build_pipeline_state(device)? };
583
584 pp_info!(
585 pp_log: &pp_log,
586 "created: {}x{}, frame_rate={}, format=D3D11(BGRA)",
587 options.width,
588 options.height,
589 options.frame_rate
590 );
591 Ok((
592 Self {
593 name: name.clone(),
594 pp_log,
595 shared: shared.clone(),
596 options,
597 frame_interval,
598 frame_index: 0,
599 device: device.clone(),
600 context,
601 vertex_shader,
602 bgra_pixel_shader,
603 nv12_pixel_shader,
604 sampler,
605 blend_state,
606 rasterizer_state,
607 layer_buffer,
608 output_pool: UnboundObjectPool::new(
609 OUTPUT_POOL_SIZE,
610 ffmpeg::frame::Video::empty,
611 |_| {},
612 ),
613 output_views: HashMap::new(),
614 pad: SrcPad::new(format!("{name}_src")),
615 },
616 D3d11VideoCompositorHandle {
617 shared: Arc::downgrade(&shared),
618 },
619 ))
620 }
621
622 pub fn width(&self) -> u32 {
623 self.options.width
624 }
625
626 pub fn height(&self) -> u32 {
627 self.options.height
628 }
629
630 pub fn frame_rate(&self) -> ffmpeg::Rational {
631 self.options.frame_rate
632 }
633
634 pub fn time_base(&self) -> ffmpeg::Rational {
635 ffmpeg::Rational::new(
636 self.options.frame_rate.denominator(),
637 self.options.frame_rate.numerator(),
638 )
639 }
640
641 fn snapshots(&self) -> Vec<InputSnapshot> {
642 let inputs: Vec<_> = self
643 .shared
644 .inputs
645 .lock()
646 .unwrap()
647 .values()
648 .cloned()
649 .collect();
650 inputs
651 .into_iter()
652 .map(|input| InputSnapshot {
653 id: input.id,
654 layer: *input.layer.lock().unwrap(),
655 frame: input.latest_frame.load_full(),
656 })
657 .collect()
658 }
659
660 fn compose_frame(
671 &mut self,
672 bus: &Bus,
673 ) -> std::result::Result<UnboundObjectPoolRef<ffmpeg::frame::Video>, D3d11VideoCompositorError>
674 {
675 let mut snapshots = self.snapshots();
676 snapshots.sort_by(|left, right| {
677 left.layer
678 .z_index
679 .cmp(&right.layer.z_index)
680 .then_with(|| left.id.cmp(&right.id))
681 });
682
683 let (canvas_width, canvas_height) = (self.options.width, self.options.height);
684 let mut output_frame = self.output_pool.get();
685 if d3d11va_texture(&output_frame).is_none() {
686 let target =
687 unsafe { create_output_target(&self.device, canvas_width, canvas_height)? };
688 let key = target.texture.as_raw() as usize;
689 self.output_views
690 .insert(key, target.render_target_view.clone());
691 *output_frame = wrap_d3d11_texture(target.texture, canvas_width, canvas_height);
692 }
693 let (output_raw, _) = d3d11va_texture(&output_frame)
694 .expect("output pool frames are initialized immediately after checkout");
695 let output_texture = unsafe {
696 ID3D11Texture2D::from_raw_borrowed(&output_raw)
697 .expect("pooled output texture pointer must not be null")
698 .clone()
699 };
700 let output_view = self
701 .output_views
702 .get(&(output_texture.as_raw() as usize))
703 .expect("every initialized output texture has a cached RTV")
704 .clone();
705
706 let context = self
707 .context
708 .lock()
709 .unwrap_or_else(|poisoned| poisoned.into_inner());
710 unsafe {
711 let background = &self.options.background;
712 context.ClearRenderTargetView(
713 &output_view,
714 &[
715 f32::from(background.red) / 255.0,
716 f32::from(background.green) / 255.0,
717 f32::from(background.blue) / 255.0,
718 1.0,
719 ],
720 );
721 context.OMSetRenderTargets(Some(&[Some(output_view)]), None);
722 context.OMSetBlendState(&self.blend_state, None, 0xffff_ffff);
723 context.RSSetState(&self.rasterizer_state);
724 context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
725 context.VSSetShader(&self.vertex_shader, None);
726 context.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
727 context.PSSetConstantBuffers(0, Some(&[Some(self.layer_buffer.clone())]));
728
729 for snapshot in &snapshots {
730 if !snapshot.layer.visible || snapshot.layer.opacity == 0.0 {
731 continue;
732 }
733 let Some(frame) = &snapshot.frame else {
734 continue;
735 };
736 if let Err(error) = self.draw_layer(
737 &context,
738 frame,
739 &snapshot.layer,
740 canvas_width,
741 canvas_height,
742 ) {
743 bus.post(
747 &self.pp_log,
748 BusEvent::Error {
749 element_type: ElementType::D3d11VideoCompositor,
750 name: self.name.clone(),
751 error: error.into(),
752 },
753 );
754 }
755 }
756
757 context.PSSetShaderResources(0, Some(&[None, None]));
760 context.OMSetRenderTargets(None, None);
761 };
762 drop(context);
763
764 output_frame.set_pts(Some(self.frame_index));
765 output_frame.set_color_space(ffmpeg::color::Space::RGB);
766 output_frame.set_color_range(ffmpeg::color::Range::JPEG);
767 self.frame_index += 1;
768 Ok(output_frame)
769 }
770
771 unsafe fn draw_layer(
772 &self,
773 context: &ID3D11DeviceContext,
774 frame: &ffmpeg::frame::Video,
775 layer: &VideoLayer,
776 canvas_width: u32,
777 canvas_height: u32,
778 ) -> std::result::Result<(), D3d11VideoCompositorError> {
779 let (texture_raw, index) =
780 d3d11va_texture(frame).ok_or(D3d11VideoCompositorError::InvalidD3d11Frame)?;
781 let texture = unsafe {
785 ID3D11Texture2D::from_raw_borrowed(&texture_raw)
786 .expect("D3d11 frame's texture pointer must not be null")
787 .clone()
788 };
789
790 let texture_device =
791 unsafe { texture.GetDevice() }.map_err(D3d11VideoCompositorError::from)?;
792 if texture_device.as_raw() != self.device.as_raw() {
793 return Err(D3d11VideoCompositorError::DeviceMismatch);
794 }
795
796 let mut desc = D3D11_TEXTURE2D_DESC::default();
797 unsafe { texture.GetDesc(&mut desc) };
798 if index < 0 || index as u64 >= u64::from(desc.ArraySize) {
799 return Err(D3d11VideoCompositorError::InvalidArrayIndex {
800 index,
801 array_size: desc.ArraySize,
802 });
803 }
804 let array_index = index as u32;
805 let uv_scale = visible_uv_scale(frame.width(), frame.height(), desc.Width, desc.Height)?;
806
807 let geometry =
808 video_layer::layer_geometry(frame.width(), frame.height(), layer.rect, layer.fit)
809 .map_err(map_layer_error)?;
810 let Some((viewport, scissor)) = clipped_viewport(&geometry, canvas_width, canvas_height)
811 else {
812 return Ok(());
813 };
814
815 let constants = match desc.Format {
816 DXGI_FORMAT_B8G8R8A8_UNORM => LayerConstants::bgra(layer.opacity, uv_scale),
817 DXGI_FORMAT_NV12 => LayerConstants::nv12(frame, layer.opacity, uv_scale),
818 other => return Err(D3d11VideoCompositorError::UnsupportedTextureFormat(other)),
819 };
820 unsafe {
821 context.UpdateSubresource(
822 &self.layer_buffer,
823 0,
824 None,
825 (&raw const constants).cast::<c_void>(),
826 0,
827 0,
828 );
829 context.RSSetViewports(Some(&[viewport]));
830 context.RSSetScissorRects(Some(&[scissor]));
831 }
832
833 match desc.Format {
834 DXGI_FORMAT_B8G8R8A8_UNORM => {
835 let srv_desc = plane_srv_desc(DXGI_FORMAT_B8G8R8A8_UNORM, array_index);
836 let mut srv = None;
837 unsafe {
838 self.device
839 .CreateShaderResourceView(&texture, Some(&srv_desc), Some(&mut srv))
840 .map_err(D3d11VideoCompositorError::from)?;
841 context.PSSetShader(&self.bgra_pixel_shader, None);
842 context.PSSetShaderResources(0, Some(&[srv]));
843 context.Draw(3, 0);
844 }
845 }
846 DXGI_FORMAT_NV12 => {
847 let luma_desc = plane_srv_desc(DXGI_FORMAT_R8_UNORM, array_index);
848 let chroma_desc = plane_srv_desc(DXGI_FORMAT_R8G8_UNORM, array_index);
849 let mut luma_srv = None;
850 let mut chroma_srv = None;
851 unsafe {
852 self.device
853 .CreateShaderResourceView(&texture, Some(&luma_desc), Some(&mut luma_srv))
854 .map_err(D3d11VideoCompositorError::from)?;
855 self.device
856 .CreateShaderResourceView(
857 &texture,
858 Some(&chroma_desc),
859 Some(&mut chroma_srv),
860 )
861 .map_err(D3d11VideoCompositorError::from)?;
862 context.PSSetShader(&self.nv12_pixel_shader, None);
863 context.PSSetShaderResources(0, Some(&[luma_srv, chroma_srv]));
864 context.Draw(3, 0);
865 }
866 }
867 _ => unreachable!("texture format was validated before updating constants"),
868 }
869 Ok(())
870 }
871
872 fn push_frame(&mut self, bus: &Bus) -> std::result::Result<(), D3d11VideoCompositorError> {
873 let output = self.compose_frame(bus)?;
874 if let Err(error) = self.pad.push(MediaBuffer::Video(Arc::new(output))) {
875 bus.post(
876 &self.pp_log,
877 BusEvent::Error {
878 element_type: ElementType::D3d11VideoCompositor,
879 name: self.name.clone(),
880 error,
881 },
882 );
883 }
884 Ok(())
885 }
886}
887
888impl Element for D3d11VideoCompositor {
889 fn name(&self) -> Arc<str> {
890 self.name.clone()
891 }
892
893 fn element_type(&self) -> ElementType {
894 ElementType::D3d11VideoCompositor
895 }
896
897 fn pp_log(&self) -> &PpLog {
898 &self.pp_log
899 }
900
901 fn pp_log_mut(&mut self) -> &mut PpLog {
902 &mut self.pp_log
903 }
904}
905
906impl Source for D3d11VideoCompositor {
907 fn src_pads(&mut self) -> &mut [SrcPad] {
908 std::slice::from_mut(&mut self.pad)
909 }
910}
911
912impl SourceElement for D3d11VideoCompositor {
913 fn run(&mut self, control: &ControlReceiver, bus: &Bus) -> Result<()> {
914 pp_info!(self, "started");
915 let mut schedule = PeriodicSchedule::new(self.frame_interval, Instant::now());
916 loop {
917 let outcome = drain_control(control, self, bus)?;
918 if outcome.stopped {
919 pp_info!(self, "stopped");
920 return Ok(());
921 }
922 if outcome.paused_for > Duration::ZERO {
923 schedule.resume_after_pause(outcome.paused_for, Instant::now());
924 }
925
926 let now = Instant::now();
927 if !schedule.is_due(now) {
928 thread::sleep(schedule.remaining(now).min(CONTROL_POLL_INTERVAL));
929 continue;
930 }
931
932 self.push_frame(bus)?;
933 schedule.advance_after_tick(Instant::now());
934 }
935 }
936
937 fn seek(&mut self, _target: Duration) -> Result<Duration> {
938 Err(D3d11VideoCompositorError::SeekUnsupported.into())
939 }
940}
941
942fn validate_output_options(
943 options: VideoCompositorOptions,
944) -> std::result::Result<(), D3d11VideoCompositorError> {
945 if options.width == 0
946 || options.height == 0
947 || options.width > MAX_DIMENSION
948 || options.height > MAX_DIMENSION
949 {
950 return Err(D3d11VideoCompositorError::InvalidOutputDimensions {
951 width: options.width,
952 height: options.height,
953 });
954 }
955 if options.frame_rate.numerator() <= 0 || options.frame_rate.denominator() <= 0 {
956 return Err(D3d11VideoCompositorError::InvalidFrameRate(
957 options.frame_rate,
958 ));
959 }
960 Ok(())
961}
962
963fn yuv_to_rgb_rows(
968 space: ffmpeg::color::Space,
969 range: ffmpeg::color::Range,
970 height: u32,
971) -> [[f32; 4]; 3] {
972 let (kr, kb) = match space {
973 ffmpeg::color::Space::BT709 => (0.2126f32, 0.0722f32),
974 ffmpeg::color::Space::BT2020NCL | ffmpeg::color::Space::BT2020CL => (0.2627f32, 0.0593f32),
975 ffmpeg::color::Space::FCC => (0.30f32, 0.11f32),
976 ffmpeg::color::Space::SMPTE240M => (0.212f32, 0.087f32),
977 ffmpeg::color::Space::Unspecified if height > 576 => (0.2126f32, 0.0722f32),
978 _ => (0.299f32, 0.114f32),
979 };
980 let kg = 1.0 - kr - kb;
981 let (y_offset, y_scale, chroma_scale) = match range {
982 ffmpeg::color::Range::JPEG => (0.0, 1.0, 1.0),
983 ffmpeg::color::Range::MPEG | ffmpeg::color::Range::Unspecified => {
984 (16.0 / 255.0, 255.0 / 219.0, 255.0 / 224.0)
985 }
986 };
987 let chroma_offset = 128.0 / 255.0;
988 let red_cr = 2.0 * (1.0 - kr) * chroma_scale;
989 let blue_cb = 2.0 * (1.0 - kb) * chroma_scale;
990 let green_cb = -2.0 * kb * (1.0 - kb) / kg * chroma_scale;
991 let green_cr = -2.0 * kr * (1.0 - kr) / kg * chroma_scale;
992 let offset = |cb: f32, cr: f32| -y_scale * y_offset - cb * chroma_offset - cr * chroma_offset;
993
994 [
995 [y_scale, 0.0, red_cr, offset(0.0, red_cr)],
996 [y_scale, green_cb, green_cr, offset(green_cb, green_cr)],
997 [y_scale, blue_cb, 0.0, offset(blue_cb, 0.0)],
998 ]
999}
1000
1001fn clipped_viewport(
1008 geometry: &LayerGeometry,
1009 canvas_width: u32,
1010 canvas_height: u32,
1011) -> Option<(D3D11_VIEWPORT, RECT)> {
1012 let output_width = i64::from(canvas_width);
1013 let output_height = i64::from(canvas_height);
1014 let clip_left = i64::from(geometry.clip.x).max(0);
1015 let clip_top = i64::from(geometry.clip.y).max(0);
1016 let clip_right =
1017 (i64::from(geometry.clip.x) + i64::from(geometry.clip.width)).min(output_width);
1018 let clip_bottom =
1019 (i64::from(geometry.clip.y) + i64::from(geometry.clip.height)).min(output_height);
1020 let left = geometry.image_x.max(clip_left);
1021 let top = geometry.image_y.max(clip_top);
1022 let right = (geometry.image_x + i64::from(geometry.image_width)).min(clip_right);
1023 let bottom = (geometry.image_y + i64::from(geometry.image_height)).min(clip_bottom);
1024 if left >= right || top >= bottom {
1025 return None;
1026 }
1027
1028 let viewport = D3D11_VIEWPORT {
1029 TopLeftX: geometry.image_x as f32,
1030 TopLeftY: geometry.image_y as f32,
1031 Width: geometry.image_width as f32,
1032 Height: geometry.image_height as f32,
1033 MinDepth: 0.0,
1034 MaxDepth: 1.0,
1035 };
1036 let scissor = RECT {
1037 left: left as i32,
1038 top: top as i32,
1039 right: right as i32,
1040 bottom: bottom as i32,
1041 };
1042 Some((viewport, scissor))
1043}
1044
1045fn plane_srv_desc(format: DXGI_FORMAT, array_index: u32) -> D3D11_SHADER_RESOURCE_VIEW_DESC {
1050 D3D11_SHADER_RESOURCE_VIEW_DESC {
1051 Format: format,
1052 ViewDimension: D3D_SRV_DIMENSION_TEXTURE2DARRAY,
1053 Anonymous: D3D11_SHADER_RESOURCE_VIEW_DESC_0 {
1054 Texture2DArray: D3D11_TEX2D_ARRAY_SRV {
1055 MostDetailedMip: 0,
1056 MipLevels: 1,
1057 FirstArraySlice: array_index,
1058 ArraySize: 1,
1059 },
1060 },
1061 }
1062}
1063
1064#[allow(clippy::type_complexity)]
1065unsafe fn build_pipeline_state(
1066 device: &ID3D11Device,
1067) -> windows::core::Result<(
1068 ID3D11VertexShader,
1069 ID3D11PixelShader,
1070 ID3D11PixelShader,
1071 ID3D11SamplerState,
1072 ID3D11BlendState,
1073 ID3D11RasterizerState,
1074 ID3D11Buffer,
1075)> {
1076 unsafe {
1077 let vertex_bytecode = compile_shader(BGRA_SHADER_SOURCE, s!("vs_main"), s!("vs_5_0"))?;
1078 let bgra_bytecode = compile_shader(BGRA_SHADER_SOURCE, s!("ps_bgra"), s!("ps_5_0"))?;
1079 let nv12_bytecode = compile_shader(NV12_SHADER_SOURCE, s!("ps_nv12"), s!("ps_5_0"))?;
1080
1081 let mut vertex_shader = None;
1082 device.CreateVertexShader(
1083 std::slice::from_raw_parts(
1084 vertex_bytecode.GetBufferPointer().cast::<u8>(),
1085 vertex_bytecode.GetBufferSize(),
1086 ),
1087 None,
1088 Some(&mut vertex_shader),
1089 )?;
1090 let vertex_shader = vertex_shader.unwrap();
1091
1092 let mut bgra_pixel_shader = None;
1093 device.CreatePixelShader(
1094 std::slice::from_raw_parts(
1095 bgra_bytecode.GetBufferPointer().cast::<u8>(),
1096 bgra_bytecode.GetBufferSize(),
1097 ),
1098 None,
1099 Some(&mut bgra_pixel_shader),
1100 )?;
1101 let bgra_pixel_shader = bgra_pixel_shader.unwrap();
1102
1103 let mut nv12_pixel_shader = None;
1104 device.CreatePixelShader(
1105 std::slice::from_raw_parts(
1106 nv12_bytecode.GetBufferPointer().cast::<u8>(),
1107 nv12_bytecode.GetBufferSize(),
1108 ),
1109 None,
1110 Some(&mut nv12_pixel_shader),
1111 )?;
1112 let nv12_pixel_shader = nv12_pixel_shader.unwrap();
1113
1114 let sampler_desc = D3D11_SAMPLER_DESC {
1115 Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
1116 AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
1117 AddressV: D3D11_TEXTURE_ADDRESS_CLAMP,
1118 AddressW: D3D11_TEXTURE_ADDRESS_CLAMP,
1119 ComparisonFunc: D3D11_COMPARISON_NEVER,
1120 MaxLOD: f32::MAX,
1121 ..Default::default()
1122 };
1123 let mut sampler = None;
1124 device.CreateSamplerState(&sampler_desc, Some(&mut sampler))?;
1125 let sampler = sampler.unwrap();
1126
1127 let mut blend_desc = D3D11_BLEND_DESC::default();
1135 blend_desc.RenderTarget[0] = D3D11_RENDER_TARGET_BLEND_DESC {
1136 BlendEnable: true.into(),
1137 SrcBlend: D3D11_BLEND_SRC_ALPHA,
1138 DestBlend: D3D11_BLEND_INV_SRC_ALPHA,
1139 BlendOp: D3D11_BLEND_OP_ADD,
1140 SrcBlendAlpha: D3D11_BLEND_ZERO,
1141 DestBlendAlpha: D3D11_BLEND_ONE,
1142 BlendOpAlpha: D3D11_BLEND_OP_ADD,
1143 RenderTargetWriteMask: D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8,
1144 };
1145 let mut blend_state = None;
1146 device.CreateBlendState(&blend_desc, Some(&mut blend_state))?;
1147 let blend_state = blend_state.unwrap();
1148
1149 let rasterizer_desc = D3D11_RASTERIZER_DESC {
1156 FillMode: D3D11_FILL_SOLID,
1157 CullMode: D3D11_CULL_NONE,
1158 ScissorEnable: true.into(),
1159 DepthClipEnable: true.into(),
1160 ..Default::default()
1161 };
1162 let mut rasterizer_state = None;
1163 device.CreateRasterizerState(&rasterizer_desc, Some(&mut rasterizer_state))?;
1164 let rasterizer_state = rasterizer_state.unwrap();
1165
1166 let buffer_desc = D3D11_BUFFER_DESC {
1167 ByteWidth: std::mem::size_of::<LayerConstants>() as u32,
1168 Usage: D3D11_USAGE_DEFAULT,
1169 BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
1170 CPUAccessFlags: 0,
1171 MiscFlags: 0,
1172 StructureByteStride: 0,
1173 };
1174 let mut layer_buffer = None;
1175 device.CreateBuffer(&buffer_desc, None, Some(&mut layer_buffer))?;
1176 let layer_buffer = layer_buffer.unwrap();
1177
1178 Ok((
1179 vertex_shader,
1180 bgra_pixel_shader,
1181 nv12_pixel_shader,
1182 sampler,
1183 blend_state,
1184 rasterizer_state,
1185 layer_buffer,
1186 ))
1187 }
1188}
1189
1190unsafe fn compile_shader(
1191 source: &[u8],
1192 entry: windows::core::PCSTR,
1193 target: windows::core::PCSTR,
1194) -> windows::core::Result<ID3DBlob> {
1195 let mut shader = None;
1196 let mut errors = None;
1197 let flags = if cfg!(debug_assertions) {
1198 D3DCOMPILE_DEBUG | D3DCOMPILE_SKIP_OPTIMIZATION
1199 } else {
1200 D3DCOMPILE_OPTIMIZATION_LEVEL3
1201 };
1202 let result = unsafe {
1203 D3DCompile(
1204 source.as_ptr().cast::<c_void>(),
1205 source.len(),
1206 s!("d3d11_video_compositor.hlsl"),
1207 None,
1208 None::<&ID3DInclude>,
1209 entry,
1210 target,
1211 flags,
1212 0,
1213 &mut shader,
1214 Some(&mut errors),
1215 )
1216 };
1217 if let Err(error) = result {
1218 let message = errors
1219 .map(|blob| unsafe {
1220 let bytes = std::slice::from_raw_parts(
1221 blob.GetBufferPointer().cast::<u8>(),
1222 blob.GetBufferSize(),
1223 );
1224 String::from_utf8_lossy(bytes).into_owned()
1225 })
1226 .unwrap_or_else(|| error.message());
1227 return Err(windows::core::Error::new(
1228 windows::Win32::Foundation::E_FAIL,
1229 message,
1230 ));
1231 }
1232 Ok(shader.unwrap())
1233}
1234
1235unsafe fn create_output_target(
1236 device: &ID3D11Device,
1237 width: u32,
1238 height: u32,
1239) -> windows::core::Result<OutputTarget> {
1240 unsafe {
1241 let desc = D3D11_TEXTURE2D_DESC {
1242 Width: width,
1243 Height: height,
1244 MipLevels: 1,
1245 ArraySize: 1,
1246 Format: DXGI_FORMAT_B8G8R8A8_UNORM,
1247 SampleDesc: DXGI_SAMPLE_DESC {
1248 Count: 1,
1249 Quality: 0,
1250 },
1251 Usage: D3D11_USAGE_DEFAULT,
1252 BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
1253 CPUAccessFlags: 0,
1254 MiscFlags: 0,
1255 };
1256 let mut texture = None;
1257 device.CreateTexture2D(&desc, None, Some(&mut texture))?;
1258 let texture = texture.unwrap();
1259
1260 let mut render_target_view = None;
1261 device.CreateRenderTargetView(&texture, None, Some(&mut render_target_view))?;
1262 let render_target_view = render_target_view.unwrap();
1263
1264 Ok(OutputTarget {
1265 texture,
1266 render_target_view,
1267 })
1268 }
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273 use std::collections::HashSet;
1274
1275 use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
1276
1277 use super::*;
1278 use crate::{
1279 color::Color,
1280 elements::{D3d11Download, VideoRect},
1281 };
1282
1283 struct CapturingSink {
1284 pp_log: PpLog,
1285 received: Arc<Mutex<Vec<MediaBuffer>>>,
1286 }
1287
1288 impl Element for CapturingSink {
1289 fn name(&self) -> Arc<str> {
1290 "capture".into()
1291 }
1292
1293 fn element_type(&self) -> ElementType {
1294 ElementType::Other
1295 }
1296
1297 fn pp_log(&self) -> &PpLog {
1298 &self.pp_log
1299 }
1300
1301 fn pp_log_mut(&mut self) -> &mut PpLog {
1302 &mut self.pp_log
1303 }
1304 }
1305
1306 impl Sink for CapturingSink {
1307 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1308 self.received.lock().unwrap().push(buf);
1309 Ok(())
1310 }
1311
1312 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1313 Ok(())
1314 }
1315 }
1316
1317 fn try_device() -> Option<(ID3D11Device, Arc<Mutex<ID3D11DeviceContext>>)> {
1321 let mut device = None;
1322 let mut context = None;
1323 let result = unsafe {
1324 D3D11CreateDevice(
1325 None,
1326 D3D_DRIVER_TYPE_HARDWARE,
1327 Default::default(),
1328 Default::default(),
1329 None,
1330 D3D11_SDK_VERSION,
1331 Some(&mut device),
1332 None,
1333 Some(&mut context),
1334 )
1335 };
1336 if result.is_err() {
1337 eprintln!("skipping: D3D11CreateDevice failed on this machine: {result:?}");
1338 return None;
1339 }
1340 Some((
1341 device.expect("D3D11CreateDevice succeeded without producing a device"),
1342 Arc::new(Mutex::new(context.expect(
1343 "D3D11CreateDevice succeeded without producing a context",
1344 ))),
1345 ))
1346 }
1347
1348 fn bgra_texture(
1349 device: &ID3D11Device,
1350 width: u32,
1351 height: u32,
1352 bgra: [u8; 4],
1353 ) -> ID3D11Texture2D {
1354 let pixels: Vec<u8> = (0..width * height).flat_map(|_| bgra).collect();
1355 bgra_texture_from_pixels(device, width, height, &pixels)
1356 }
1357
1358 fn bgra_texture_from_pixels(
1359 device: &ID3D11Device,
1360 width: u32,
1361 height: u32,
1362 pixels: &[u8],
1363 ) -> ID3D11Texture2D {
1364 assert_eq!(pixels.len(), (width * height * 4) as usize);
1365 unsafe {
1366 let desc = D3D11_TEXTURE2D_DESC {
1367 Width: width,
1368 Height: height,
1369 MipLevels: 1,
1370 ArraySize: 1,
1371 Format: DXGI_FORMAT_B8G8R8A8_UNORM,
1372 SampleDesc: DXGI_SAMPLE_DESC {
1373 Count: 1,
1374 Quality: 0,
1375 },
1376 Usage: D3D11_USAGE_DEFAULT,
1377 BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
1378 CPUAccessFlags: 0,
1379 MiscFlags: 0,
1380 };
1381 let initial = D3D11_SUBRESOURCE_DATA {
1382 pSysMem: pixels.as_ptr().cast::<c_void>(),
1383 SysMemPitch: width * 4,
1384 SysMemSlicePitch: 0,
1385 };
1386 let mut texture = None;
1387 device
1388 .CreateTexture2D(&desc, Some(&initial), Some(&mut texture))
1389 .expect("CreateTexture2D failed");
1390 texture.expect("CreateTexture2D succeeded without producing a texture")
1391 }
1392 }
1393
1394 fn nv12_texture(
1395 device: &ID3D11Device,
1396 width: u32,
1397 height: u32,
1398 y: u8,
1399 cb: u8,
1400 cr: u8,
1401 ) -> ID3D11Texture2D {
1402 let row_bytes = width as usize;
1403 let luma_size = row_bytes * height as usize;
1404 let mut pixels = vec![y; luma_size + row_bytes * height.div_ceil(2) as usize];
1405 for pair in pixels[luma_size..].chunks_exact_mut(2) {
1406 pair.copy_from_slice(&[cb, cr]);
1407 }
1408 unsafe {
1409 let desc = D3D11_TEXTURE2D_DESC {
1410 Width: width,
1411 Height: height,
1412 MipLevels: 1,
1413 ArraySize: 1,
1414 Format: DXGI_FORMAT_NV12,
1415 SampleDesc: DXGI_SAMPLE_DESC {
1416 Count: 1,
1417 Quality: 0,
1418 },
1419 Usage: D3D11_USAGE_DEFAULT,
1420 BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
1421 CPUAccessFlags: 0,
1422 MiscFlags: 0,
1423 };
1424 let initial = D3D11_SUBRESOURCE_DATA {
1425 pSysMem: pixels.as_ptr().cast::<c_void>(),
1426 SysMemPitch: width,
1427 SysMemSlicePitch: 0,
1428 };
1429 let mut texture = None;
1430 device
1431 .CreateTexture2D(&desc, Some(&initial), Some(&mut texture))
1432 .expect("CreateTexture2D(NV12) failed");
1433 texture.expect("CreateTexture2D succeeded without producing a texture")
1434 }
1435 }
1436
1437 fn texture_key(frame: &ffmpeg::frame::Video) -> usize {
1438 d3d11va_texture(frame).expect("expected a D3D11 frame").0 as usize
1439 }
1440
1441 fn apply_color_rows(rows: [[f32; 4]; 3], y: f32, cb: f32, cr: f32) -> [f32; 3] {
1442 rows.map(|row| row[0] * y + row[1] * cb + row[2] * cr + row[3])
1443 }
1444
1445 fn pooled_video(frame: ffmpeg::frame::Video) -> MediaBuffer {
1446 let pool = UnboundObjectPool::new(0, ffmpeg::frame::Video::empty, |_| {});
1447 let mut pooled = pool.get();
1448 *pooled = frame;
1449 MediaBuffer::Video(Arc::new(pooled))
1450 }
1451
1452 fn test_bus() -> Bus {
1456 Bus::new().0
1457 }
1458
1459 fn download_frame(
1460 device: &ID3D11Device,
1461 context: Arc<Mutex<ID3D11DeviceContext>>,
1462 composed: UnboundObjectPoolRef<ffmpeg::frame::Video>,
1463 ) -> Arc<UnboundObjectPoolRef<ffmpeg::frame::Video>> {
1464 let (width, height) = (composed.width(), composed.height());
1465 let mut download = D3d11Download::new("download", device, context, width, height)
1466 .expect("D3d11Download::new should succeed");
1467 let received = Arc::new(Mutex::new(Vec::new()));
1468 download.src_pads()[0].link(Box::new(CapturingSink {
1469 received: received.clone(),
1470 pp_log: element_pp_log(ElementType::Other, "capture", None),
1471 }));
1472 download
1473 .consume(MediaBuffer::Video(Arc::new(composed)))
1474 .expect("download consume should succeed");
1475 let mut received = received.lock().unwrap();
1476 let MediaBuffer::Video(frame) = received.remove(0) else {
1477 panic!("expected a Video buffer");
1478 };
1479 frame
1480 }
1481
1482 fn pixel(frame: &ffmpeg::frame::Video, x: usize, y: usize) -> [u8; 4] {
1483 let offset = y * frame.stride(0) + x * 4;
1484 frame.data(0)[offset..offset + 4].try_into().unwrap()
1485 }
1486
1487 #[test]
1488 fn invalid_text_layer_does_not_replace_an_existing_registration() {
1489 let Some((device, context)) = try_device() else {
1490 return;
1491 };
1492 let options = VideoCompositorOptions {
1493 width: 4,
1494 height: 4,
1495 frame_rate: ffmpeg::Rational::new(30, 1),
1496 background: Color::BLACK,
1497 };
1498 let (_compositor, handle) =
1499 D3d11VideoCompositor::new("compositor", &device, context, options).unwrap();
1500 let existing = handle
1501 .add_layer("overlay", VideoLayer::new(VideoRect::new(0, 0, 1, 1)))
1502 .unwrap()
1503 .unwrap();
1504
1505 let result = handle.add_text_layer("overlay", TextLayer::new(vec![0, 1, 2, 3]));
1506
1507 assert!(matches!(result, Err(D3d11TextLayerError::InvalidFont(_))));
1508 assert_eq!(handle.source_count(), 1);
1509 assert!(existing.layer().is_some());
1510 }
1511
1512 #[test]
1513 fn composes_gpu_inputs_in_z_order_and_preserves_output_contract() {
1514 let Some((device, context)) = try_device() else {
1515 return;
1516 };
1517 let options = VideoCompositorOptions {
1518 width: 4,
1519 height: 4,
1520 frame_rate: ffmpeg::Rational::new(30, 1),
1521 background: Color::BLACK,
1522 };
1523 let (mut compositor, handle) =
1524 D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1525 .expect("D3d11VideoCompositor::new should succeed");
1526
1527 let mut background_layer = VideoLayer::new(VideoRect::new(0, 0, 4, 4));
1528 background_layer.fit = video_layer::VideoFit::Stretch;
1529 let mut red_sink = handle
1530 .add_source("red", background_layer)
1531 .unwrap()
1532 .unwrap()
1533 .sink;
1534
1535 let mut overlay_layer = VideoLayer::new(VideoRect::new(1, 1, 2, 2));
1536 overlay_layer.z_index = 1;
1537 overlay_layer.fit = video_layer::VideoFit::Stretch;
1538 let mut blue_sink = handle
1539 .add_source("blue", overlay_layer)
1540 .unwrap()
1541 .unwrap()
1542 .sink;
1543
1544 let red_texture = bgra_texture(&device, 4, 4, [0, 0, 255, 255]);
1546 let blue_texture = bgra_texture(&device, 2, 2, [255, 0, 0, 255]);
1547 red_sink
1548 .consume(pooled_video(wrap_d3d11_texture(red_texture, 4, 4)))
1549 .unwrap();
1550 blue_sink
1551 .consume(pooled_video(wrap_d3d11_texture(blue_texture, 2, 2)))
1552 .unwrap();
1553
1554 let composed = compositor
1555 .compose_frame(&test_bus())
1556 .expect("compose_frame failed");
1557 assert_eq!(composed.format(), ffmpeg::format::Pixel::D3D11);
1558 assert_eq!((composed.width(), composed.height()), (4, 4));
1559 assert_eq!(composed.pts(), Some(0));
1560
1561 let downloaded = download_frame(&device, context, composed);
1562 assert_eq!(pixel(&downloaded, 0, 0), [0, 0, 255, 255], "red background");
1563 assert_eq!(pixel(&downloaded, 1, 1), [255, 0, 0, 255], "blue overlay");
1564 }
1565
1566 #[test]
1567 fn ignores_rows_outside_the_frame_visible_dimensions() {
1568 let Some((device, context)) = try_device() else {
1569 return;
1570 };
1571 let options = VideoCompositorOptions {
1572 width: 4,
1573 height: 3,
1574 frame_rate: ffmpeg::Rational::new(30, 1),
1575 background: Color::BLACK,
1576 };
1577 let (mut compositor, handle) =
1578 D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1579 .expect("D3d11VideoCompositor::new should succeed");
1580 let mut layer = VideoLayer::new(VideoRect::new(0, 0, 4, 3));
1581 layer.fit = video_layer::VideoFit::Stretch;
1582 let mut sink = handle.add_source("input", layer).unwrap().unwrap().sink;
1583
1584 let mut pixels = Vec::with_capacity(4 * 4 * 4);
1588 for y in 0..4 {
1589 let color = if y < 3 {
1590 [0, 0, 255, 255]
1591 } else {
1592 [255, 0, 0, 255]
1593 };
1594 pixels.extend((0..4).flat_map(|_| color));
1595 }
1596 let texture = bgra_texture_from_pixels(&device, 4, 4, &pixels);
1597 sink.consume(pooled_video(wrap_d3d11_texture(texture, 4, 3)))
1598 .unwrap();
1599
1600 let composed = compositor
1601 .compose_frame(&test_bus())
1602 .expect("compose_frame failed");
1603 let downloaded = download_frame(&device, context, composed);
1604 for y in 0..3 {
1605 for x in 0..4 {
1606 assert_eq!(pixel(&downloaded, x, y), [0, 0, 255, 255]);
1607 }
1608 }
1609 }
1610
1611 #[test]
1612 fn rejects_frame_dimensions_larger_than_the_backing_texture() {
1613 let error = visible_uv_scale(1920, 1088, 1920, 1080).unwrap_err();
1614 assert!(matches!(
1615 error,
1616 D3d11VideoCompositorError::FrameExceedsTexture {
1617 frame_width: 1920,
1618 frame_height: 1088,
1619 texture_width: 1920,
1620 texture_height: 1080,
1621 }
1622 ));
1623 }
1624
1625 #[test]
1626 fn live_output_frames_keep_distinct_textures_until_the_last_arc_drops() {
1627 let Some((device, context)) = try_device() else {
1628 return;
1629 };
1630 let options = VideoCompositorOptions {
1631 width: 1,
1632 height: 1,
1633 frame_rate: ffmpeg::Rational::new(30, 1),
1634 background: Color::BLACK,
1635 };
1636 let (mut compositor, handle) =
1637 D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1638 .expect("D3d11VideoCompositor::new should succeed");
1639 let mut layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1640 layer.fit = video_layer::VideoFit::Stretch;
1641 let mut sink = handle.add_source("input", layer).unwrap().unwrap().sink;
1642
1643 sink.consume(pooled_video(wrap_d3d11_texture(
1644 bgra_texture(&device, 1, 1, [0, 0, 255, 255]),
1645 1,
1646 1,
1647 )))
1648 .unwrap();
1649 let first = compositor
1650 .compose_frame(&test_bus())
1651 .expect("first compose failed");
1652
1653 sink.consume(pooled_video(wrap_d3d11_texture(
1654 bgra_texture(&device, 1, 1, [255, 0, 0, 255]),
1655 1,
1656 1,
1657 )))
1658 .unwrap();
1659 let mut later = Vec::new();
1660 for _ in 0..OUTPUT_POOL_SIZE {
1661 later.push(
1662 compositor
1663 .compose_frame(&test_bus())
1664 .expect("later compose failed"),
1665 );
1666 }
1667
1668 let mut keys = HashSet::new();
1669 keys.insert(texture_key(&first));
1670 keys.extend(later.iter().map(|frame| texture_key(frame)));
1671 assert_eq!(
1672 keys.len(),
1673 OUTPUT_POOL_SIZE + 1,
1674 "simultaneously-live output frames must never alias one texture"
1675 );
1676
1677 let downloaded = download_frame(&device, context, first);
1678 assert_eq!(
1679 pixel(&downloaded, 0, 0),
1680 [0, 0, 255, 255],
1681 "later compositions must not overwrite a queued first frame"
1682 );
1683 }
1684
1685 #[test]
1686 fn nv12_conversion_uses_frame_color_space_and_range() {
1687 let Some((device, context)) = try_device() else {
1688 return;
1689 };
1690 let options = VideoCompositorOptions {
1691 width: 2,
1692 height: 2,
1693 frame_rate: ffmpeg::Rational::new(30, 1),
1694 background: Color::BLACK,
1695 };
1696 let (mut compositor, handle) =
1697 D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1698 .expect("D3d11VideoCompositor::new should succeed");
1699 let mut layer = VideoLayer::new(VideoRect::new(0, 0, 2, 2));
1700 layer.fit = video_layer::VideoFit::Stretch;
1701 let mut sink = handle.add_source("input", layer).unwrap().unwrap().sink;
1702 let texture = nv12_texture(&device, 2, 2, 81, 90, 240);
1703
1704 let mut bt601 = wrap_d3d11_texture(texture.clone(), 2, 2);
1705 bt601.set_color_space(ffmpeg::color::Space::SMPTE170M);
1706 bt601.set_color_range(ffmpeg::color::Range::MPEG);
1707 sink.consume(pooled_video(bt601)).unwrap();
1708 let bt601 = compositor
1709 .compose_frame(&test_bus())
1710 .expect("BT.601 compose failed");
1711 let bt601 = download_frame(&device, context.clone(), bt601);
1712
1713 let mut bt709 = wrap_d3d11_texture(texture, 2, 2);
1714 bt709.set_color_space(ffmpeg::color::Space::BT709);
1715 bt709.set_color_range(ffmpeg::color::Range::MPEG);
1716 sink.consume(pooled_video(bt709)).unwrap();
1717 let bt709 = compositor
1718 .compose_frame(&test_bus())
1719 .expect("BT.709 compose failed");
1720 let bt709 = download_frame(&device, context, bt709);
1721
1722 let pixel_601 = pixel(&bt601, 0, 0);
1723 let pixel_709 = pixel(&bt709, 0, 0);
1724 assert!(
1725 pixel_601[1].abs_diff(pixel_709[1]) >= 20,
1726 "the same NV12 sample should use different 601/709 matrices: {pixel_601:?} vs {pixel_709:?}"
1727 );
1728 assert_eq!(pixel_601[3], 255);
1729 assert_eq!(pixel_709[3], 255);
1730 }
1731
1732 #[test]
1733 fn nv12_conversion_distinguishes_limited_and_full_range() {
1734 let limited = yuv_to_rgb_rows(
1735 ffmpeg::color::Space::BT709,
1736 ffmpeg::color::Range::MPEG,
1737 1080,
1738 );
1739 let full = yuv_to_rgb_rows(
1740 ffmpeg::color::Space::BT709,
1741 ffmpeg::color::Range::JPEG,
1742 1080,
1743 );
1744 let neutral = 128.0 / 255.0;
1745 let limited_black = apply_color_rows(limited, 16.0 / 255.0, neutral, neutral);
1746 let limited_white = apply_color_rows(limited, 235.0 / 255.0, neutral, neutral);
1747 let full_black = apply_color_rows(full, 0.0, neutral, neutral);
1748 let full_white = apply_color_rows(full, 1.0, neutral, neutral);
1749
1750 for channel in limited_black.into_iter().chain(full_black) {
1751 assert!(channel.abs() < 1e-5, "black mapped to {channel}");
1752 }
1753 for channel in limited_white.into_iter().chain(full_white) {
1754 assert!((channel - 1.0).abs() < 1e-5, "white mapped to {channel}");
1755 }
1756 }
1757
1758 #[test]
1759 fn layer_handle_moves_blends_and_hides_a_live_source() {
1760 let Some((device, context)) = try_device() else {
1761 return;
1762 };
1763 let options = VideoCompositorOptions {
1764 width: 3,
1765 height: 1,
1766 frame_rate: ffmpeg::Rational::new(30, 1),
1767 background: Color::BLACK,
1768 };
1769 let (mut compositor, handle) =
1770 D3d11VideoCompositor::new("compositor", &device, context.clone(), options)
1771 .expect("D3d11VideoCompositor::new should succeed");
1772
1773 let layer = VideoLayer::new(VideoRect::new(0, 0, 1, 1));
1774 let input = handle.add_source("white", layer).unwrap().unwrap();
1775 let mut sink = input.sink;
1776 let layer_handle = input.layer;
1777
1778 let white_texture = bgra_texture(&device, 1, 1, [255, 255, 255, 255]);
1779 sink.consume(pooled_video(wrap_d3d11_texture(white_texture, 1, 1)))
1780 .unwrap();
1781
1782 layer_handle.set_rect(VideoRect::new(1, 0, 1, 1)).unwrap();
1783 layer_handle.set_opacity(0.5).unwrap();
1784 let blended = compositor
1785 .compose_frame(&test_bus())
1786 .expect("compose_frame failed");
1787 let downloaded = download_frame(&device, context.clone(), blended);
1788 assert_eq!(pixel(&downloaded, 0, 0), [0, 0, 0, 255], "background only");
1789 let blended_pixel = pixel(&downloaded, 1, 0);
1796 assert_eq!(
1797 blended_pixel[3], 255,
1798 "50% white over black: {blended_pixel:?}"
1799 );
1800 for channel in &blended_pixel[..3] {
1801 assert!(
1802 (127..=128).contains(channel),
1803 "50% white over black: {blended_pixel:?}"
1804 );
1805 }
1806
1807 layer_handle.set_visible(false).unwrap();
1808 let hidden = compositor
1809 .compose_frame(&test_bus())
1810 .expect("compose_frame failed");
1811 assert_eq!(hidden.pts(), Some(1));
1812 let downloaded = download_frame(&device, context, hidden);
1813 assert_eq!(pixel(&downloaded, 1, 0), [0, 0, 0, 255], "hidden layer");
1814 }
1815
1816 #[test]
1817 fn skips_a_mismatched_device_texture_and_reports_it_on_the_bus() {
1818 let Some((device_a, context_a)) = try_device() else {
1819 return;
1820 };
1821 let Some((device_b, _context_b)) = try_device() else {
1822 return;
1823 };
1824 let options = VideoCompositorOptions {
1825 width: 1,
1826 height: 1,
1827 frame_rate: ffmpeg::Rational::new(30, 1),
1828 background: Color::BLACK,
1829 };
1830 let (mut compositor, handle) =
1831 D3d11VideoCompositor::new("compositor", &device_a, context_a.clone(), options)
1832 .expect("D3d11VideoCompositor::new should succeed");
1833 let mut sink = handle
1834 .add_source("mismatched", VideoLayer::new(VideoRect::new(0, 0, 1, 1)))
1835 .unwrap()
1836 .unwrap()
1837 .sink;
1838
1839 let foreign_texture = bgra_texture(&device_b, 1, 1, [255, 255, 255, 255]);
1840 sink.consume(pooled_video(wrap_d3d11_texture(foreign_texture, 1, 1)))
1841 .unwrap();
1842
1843 let (bus, bus_rx) = Bus::new();
1844 let composed = compositor
1845 .compose_frame(&bus)
1846 .expect("a mismatched-device layer must be skipped, not fail the whole frame");
1847
1848 let error = match bus_rx
1849 .try_recv()
1850 .expect("the skipped layer should be reported on the bus")
1851 {
1852 BusEvent::Error { error, .. } => error,
1853 other => panic!("expected a BusEvent::Error, got {other:?}"),
1854 };
1855 assert!(matches!(
1856 error,
1857 crate::error::Error::D3d11VideoCompositorError(
1858 D3d11VideoCompositorError::DeviceMismatch
1859 )
1860 ));
1861
1862 let downloaded = download_frame(&device_a, context_a, composed);
1863 assert_eq!(
1864 pixel(&downloaded, 0, 0),
1865 [0, 0, 0, 255],
1866 "mismatched-device layer must not be drawn — background only"
1867 );
1868 }
1869
1870 struct TimestampSink {
1871 pp_log: PpLog,
1872 tx: crossbeam_channel::Sender<Instant>,
1873 }
1874
1875 impl Element for TimestampSink {
1876 fn name(&self) -> Arc<str> {
1877 "timestamp-recorder".into()
1878 }
1879 fn element_type(&self) -> ElementType {
1880 ElementType::Other
1881 }
1882 fn pp_log(&self) -> &PpLog {
1883 &self.pp_log
1884 }
1885 fn pp_log_mut(&mut self) -> &mut PpLog {
1886 &mut self.pp_log
1887 }
1888 }
1889
1890 impl Sink for TimestampSink {
1891 fn consume(&mut self, buf: MediaBuffer) -> Result<()> {
1892 if matches!(buf, MediaBuffer::Video(_)) {
1893 let _ = self.tx.send(Instant::now());
1894 }
1895 Ok(())
1896 }
1897 fn control(&mut self, _msg: ControlMsg) -> Result<()> {
1898 Ok(())
1899 }
1900 }
1901
1902 #[test]
1907 fn resuming_after_a_pause_preserves_output_phase() {
1908 use crate::pipeline::Pipeline;
1909
1910 let Some((device, context)) = try_device() else {
1911 return;
1912 };
1913 let (tx, rx) = crossbeam_channel::unbounded();
1914 let sink = TimestampSink {
1915 tx,
1916 pp_log: element_pp_log(ElementType::Other, "timestamp-recorder", None),
1917 };
1918 let options = VideoCompositorOptions {
1919 width: 2,
1920 height: 2,
1921 frame_rate: ffmpeg::Rational::new(10, 1),
1922 background: Color::BLACK,
1923 };
1924 let (compositor, _handle) =
1925 D3d11VideoCompositor::new("compositor", &device, context, options)
1926 .expect("D3d11VideoCompositor::new should succeed");
1927
1928 let pipeline = Pipeline::new("phase-test", compositor, |source, ctx| {
1929 let branch = ctx.branch().to(Box::new(sink))?;
1930 ctx.attach(source, 0, branch)?;
1931 Ok(())
1932 })
1933 .expect("test pipeline wiring must succeed");
1934
1935 pipeline.run();
1936 for _ in 0..2 {
1937 rx.recv_timeout(Duration::from_millis(500))
1938 .expect("expected steady frames before pausing");
1939 }
1940 pipeline.pause();
1941 thread::sleep(Duration::from_millis(500));
1942
1943 let resumed_at = Instant::now();
1944 pipeline.resume();
1945 let first_after_resume = rx
1946 .recv_timeout(Duration::from_millis(500))
1947 .expect("expected a frame after resume");
1948 pipeline.stop();
1949 pipeline.bus().log_events();
1950
1951 let gap = first_after_resume.saturating_duration_since(resumed_at);
1952 assert!(
1953 gap >= Duration::from_millis(50),
1954 "expected the post-pause frame to land close to a full 100ms \
1955 interval after resume (phase preserved from before the \
1956 pause), not almost immediately (phase reset to the resume \
1957 instant): got {gap:?}"
1958 );
1959 }
1960}